Repository navigation
Conversation
Users can now suggest a new tag from the tag picker (source-cooperative/source.coop feat/tag-suggestions), so describe that instead of emailing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Suggested tags no longer appear publicly before an admin approves them (source-cooperative/source.coop#648). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows source-cooperative/source.coop#648, where a suggested tag waits in the form until the product is saved, and tags are spelled with hyphens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach
added a commit
to source-cooperative/source.coop
that referenced
this pull request
Oct 28, 2026
…648) People can suggest a tag the list doesn't have, straight from the product form's tag picker, and admins review suggestions on a new admin page.         ## How it works 1. **Suggest.** The picker's popover lists the product's own tags first, checked, then every other tag, filtered as you type; the arrow keys move through it and Enter toggles a tag. When the filter text isn't an existing tag, its last row offers *Suggest "sea-ice" as a new tag*. The offer also appears when other tags only partly match, so someone after `ocean-acidification` isn't stuck with `ocean`. Choosing it adds a chip marked *(new)*. Nothing reaches the server yet. 2. **Saving the product suggests it.** The form submits new tags as `suggest_tags`. `createProduct` and `updateProduct` check them with `planSuggestions` (spelling, and the 5-suggestion cap), check the product's tags with them, finish every other check, and only then write the pending tags (`pending: true`, `suggested_by`, `suggested_at` in `sc-{stage}-tags`) and the product. A save that fails suggests nothing, and leaving without saving suggests nothing, so a suggestion can't be left on no product. A refused suggestion shows under the Tags field. 3. **An admin reviews it** at `/admin/tags`, which lists each pending tag, who suggested it, and the products carrying it. Each decision calls the same operation as the matching API route: - **Approve as a new tag** (a soft button on every card, so **Save merge** is the one solid button) removes the pending flag, so the tag joins the list everyone picks from. - **Merge** takes two steps, and can go into one tag or several. The card offers the approved tags the suggestion looks most like, and **Other tag** (or **Merge into a tag** when nothing is close), an outlined button with a chevron, opens every approved tag with how many products carry it, the suggestion's close matches first, picked with the mouse or the arrow keys and Enter. Each one chosen toggles in or out of the staged merge. When every word of the suggestion is already an approved tag (in the singular or the plural, via `splitTag`), one button such as **field + boundaries** stages them all. The card then says what saving will change, nothing happens until **Save merge**, and **Reset** (on the right, in orange) goes back to the suggestion as it was. Saving puts every chosen tag in the suggestion's place on each product carrying it, once each, and records the decision. - **Reject** takes it off every product carrying it and records the decision. Approve and Reject act at once. A refused decision, such as a tag another admin already handled, is shown on its card. 4. **Every decision stays on record, and can be undone.** A suggestion no longer disappears once decided. Its row keeps `outcome` (approved, merged or rejected), `reviewed_by`, `reviewed_at`, the `merged_into` targets and, for a merge or rejection, `changed`: each product it rewrote and the tags each gained. Merged and rejected tags leave the list, the picker and the save check (`isListed`, `isUsable`), but stay in the table. The **Reviewed** section of `/admin/tags` lists them most recent first, with the decision, who made it and when, the products the tag was on (for a merge or rejection, those it rewrote; for an approval, those carrying it now), each linked, and **Undo**. Undo puts the tag back awaiting review; for a merge or rejection it first puts the tag back on each product it was taken off and takes off the tags the merge added, leaving any the product had before. A merge or rejection is recorded before products change, and only while the tag is still pending, so two admins can't both decide one. Suggesting a tag that was merged or rejected puts it back in the queue. Below that, an **All tags** table lists every approved tag, most used first, with its product count and where it came from. Each tag links to the product list filtered by it (`/products?tags=boundaries`). Tags seeded by `clean-tags.ts` show as *Initial import*. ## API Suggesting and reviewing go through operations in `src/lib/operations/tags.ts`. The form actions and these routes both adapt them, so the UI and the API can't disagree about a rule: | Endpoint | Does | Who may | |---|---|---| | `GET /api/v1/tags` | Lists the approved tags, as one page. Admins also see who suggested and approved each, and when | Anyone | | `GET /api/v1/tags?pending=true` | Lists the tags awaiting review, with who suggested each and when | Admins | | `GET /api/v1/tags?reviewed=true` | Lists the suggested tags an admin decided, most recent first, with the decision and what it changed | Admins | | `POST /api/v1/tags` `{ "tag_id": "sea-ice" }` | Suggests a tag on its own: 201 with the new pending tag, or 200 with the tag if it already exists | Any signed-in account | | `POST`/`PATCH` on products with `suggest_tags` | Suggests tags and adds them to the product in the same save; nothing is suggested if the save fails | Whoever may create or edit the product | | `PATCH /api/v1/tags/{tag_id}` `{ "pending": false }` | Approves a tag | Admins | | `PATCH /api/v1/tags/{tag_id}` `{ "pending": true }` | Undoes a decision, reversing what a merge or rejection did to products | Admins | | `DELETE /api/v1/tags/{tag_id}` | Rejects a pending tag, taking it off every product; it stays on record | Admins | | `DELETE /api/v1/tags/{tag_id}?replace_with=field&replace_with=boundaries` | Merges a pending tag into one or more approved tags on every product; repeat `replace_with` for each | Admins | How this fits #702: - **Errors** are the existing RFC 9457 problems from #701: 400 `invalid` for a malformed tag or a `replace_with` that isn't approved, 409 `conflict` past the five-suggestion cap or for deleting an approved tag, and 401 or 403 from `deny`. No new problem types. - **The reference** is the OpenAPI 3.1 document from #710; both route files are imported in `src/app/api/openapi/route.ts`. - **Safe retries (#708):** `POST /tags` answers 200 and the existing tag rather than 409, so a retried suggestion is harmless without an `Idempotency-Key`. API.md now states this convention for creates named by their own content. - **Conditional writes (#703):** the 409 for deleting a tag that is no longer pending is the stale-form case `If-Match` will cover generally. Once #703 lands, the review page could send the ETag it read instead. - **`WWW-Authenticate` (#704)** will come to these routes' 401s through `toResponse`, with nothing to change here. ## Guardrails - Suggestions are spelled like every other tag: trimmed, lowercased, with hyphens for spaces (`canonTag` in `src/lib/tags/canon.ts`, the same file #719 adds), so typing `Sea Ice` suggests `sea-ice`. They must then be at most 40 letters, digits or hyphens, starting and ending with a letter or digit. The picker filters and offers suggestions in that spelling too, so its button names the tag as it will be stored. - Each account can have at most 5 pending suggestions. - Suggesting a tag that already exists, approved or pending, just returns it. Writes are conditional, so two people suggesting the same tag at once can't overwrite each other. - Only logged-in users can suggest. Only admins can review, which the operations check themselves rather than relying on the `/admin` layout. - `deleteTag` refuses to merge or reject a tag that is no longer pending. Without that, a second admin's stale form could delete a tag another admin just approved, and strip it from every product. - A pending tag stays private in filters too: `listProducts` matches `?tags=` again on the tags the caller can see, so filtering by a tag awaiting review doesn't list the products carrying it. - A merge, rejection or undo here rewrites each product with an unconditioned write, one at a time, so an owner's save at the same moment can be overwritten and a failure partway leaves some products unchanged. #723, stacked on this PR, replaces those writes with conditional DynamoDB transactions. ## Unsaved changes The *You have unsaved changes* line by Save, the disabled Save, and the ask before leaving a form with unsaved changes moved to #722, now merged into `main`. The tag picker relies on it: a *(new)* tag is only suggested once the product is saved, and #722 counts its hidden inputs as changes. ## Account names Every account `/admin/tags` names (who suggested a tag, who approved, merged or rejected it) links to the account and shows its card on hover. A new `AccountMention` (`src/components/core`) wraps an id in running text in the existing `AccountInfoHoverCard`, and shows an id the page couldn't look up, such as a deleted account's, as plain text. The page looks up every account it names in one batch and hands the components their `publicView`, so no private field reaches the browser. This follows `CLAUDE.md`'s *Account names* rule, which is on `main` (#728); this PR doesn't change `CLAUDE.md`.  ## Decisions worth a second look - **Likely matches are a guess, so they are only offered.** `similarTags` (`src/lib/tags/similar.ts`) ranks approved tags by spelling: the same letters without separators, one containing the other, shared words, or a couple of letters off. It knows no synonyms, so `sst` finds nothing like `ocean`; **Other tag** covers that. Because a guess can be wrong, a match only stages the merge, and the card says what saving will change before anything does. - **Pending tags appear only in the product's settings.** `hidePendingTags` (`src/lib/clients/database/tags.ts`) takes them off products shown on the product page, the products list, profiles and the homepage. The API's product reads apply it too, except for callers who may edit the product: they need to see every tag, or a tags list they send back would drop pending ones unseen. It costs one scan of the tags table per request, which the request cache shares. The `?tags=` search filter still matches a pending tag, so filtering by one lists its products, without showing the tag on them. - **One table, not a separate suggestions table.** Approving a tag means the app writes to the tag list anyway, so a separate table wouldn't keep the list read-only. The Vercel role's grant on `sc-{stage}-tags` becomes read-write. - **A dot, not a count or a message.** While any tag is pending, admins see a red dot on their avatar, on Admin in the account menu, on Tags inside it and on Tags in the admin sidebar, the same way a pending invitation shows. `anyPendingTags` answers it from the tags scan `hidePendingTags` already makes, which the request cache shares, and says no when the table can't be read so the header never fails over it. A count, or a Slack message, could follow if suggestions still sit unreviewed.  - **A rejection doesn't tell the suggester why.** The tag just disappears from their product. A rejection note could follow if people ask. - **Merge and reject scan the products table** once per decision, and the admin page scans it once per pending tag (marked with a `ponytail:` comment). That's fine while the queue is short and there are a few hundred products. - **Running `clean-tags.ts` after this ships** would drop pending tags from products, because they aren't in its corpus. It's meant to run once, at rollout, before any suggestions exist. ## The tag menus Both pickers, the product form's and the admin merge picker, share one list, `TagMenu` (`src/components/features/products/TagMenu.tsx`): a search field heading plain option rows in titled groups, with the active row tracked through `aria-activedescendant`. Their rows used to be ghost `Button`s, whose negative margins pushed them past the scrolling box, clipping their first letters and scrolling the list sideways, and whose full height made the hover highlight oversized. Options stay inside the list's edges and highlight one 32 px row. The product's tags are held in place while the menu is open, so a tag toggled off doesn't jump to the other group under the cursor. Enter never submits the form behind the popover. ## Stories On this branch's Storybook deploy: - TagMenu (new): [MergeTargets](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--merge-targets), [ProductTags](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--product-tags), [NoMatch](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--no-match) - TagPicker: [Filtering](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--filtering), [Suggesting](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--suggesting), [NewTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--new-tag) (suggested, not yet saved), [PendingTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--pending-tag) - AdminTagSuggestions: [Pending](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--pending), [MergeStaged](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--merge-staged), [Refused](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--refused), [Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--empty), [MergeIntoSeveral](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--merge-into-several), [Split](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--split), [SplitStaged](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--split-staged), [OtherTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--other-tag) (the merge picker open) - AdminReviewedTags: [Reviewed](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--reviewed), [UndoRefused](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--undo-refused), [Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--empty), [AccountCard](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--account-card) (a name hovered) - AdminTagList: [AllTags](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--all-tags), [Filtered](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--filtered), [Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--empty) - AccountDropdown (new): [Default](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--default), [Invited](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--invited), [TagsToReview](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--tags-to-review) `.storybook/preview.tsx` mocks the new `src/lib/actions/tags.ts` module, and `src/lib/actions/credentials.ts`, which the AccountDropdown story reaches through the uploader providers. ## Docs - `CLAUDE.md` is unchanged here: its *Account names* rule landed on `main` in #728, and this PR is rebased on it. - `docs/using-source/create-a-data-product.md` told people to email hello@source.coop for a missing tag. Updated in [docs.source.coop#40](source-cooperative/docs.source.coop#40), stacked on #39. - `public/llms.txt` gets a line for `GET /api/v1/tags`, next to product search, since `?tags=` filters on those tags. It needs no credentials, so the URL resolves in production once this ships. Tags have no bearing on the data-proxy ADRs (authorization, credentials, federation), and the proxy calls none of these endpoints, so its contract test is unaffected. - `API.md` gains the rule that a create named by its own content answers 200 with the existing resource. ## Testing - The full jest suite passes locally (1253 tests), as does `npm run type-check`. `npm run lint` reports nothing in the changed files. `src/components/core/AccountMention.test.tsx` covers the link to a looked-up account and the plain-text fallback. Undo is covered in `src/lib/operations/tags.test.ts`: a merge restoring the tag and taking off only what it added (a product that had a target before keeps it, a deleted product is skipped), an approval only reopening, nothing to undo on a seeded tag, and re-suggesting a rejected tag. The same file covers recording each decision before products change, a race losing with 409 and no product changed, and the reviewed list. `AdminReviewedTags.test.tsx` covers the decision wording, the products each tag was on, and Undo. - New `src/components/features/admin/AdminTagSuggestions.test.tsx` covers choosing a match only staging the merge, Reset going back, Save merge sending the merge and its target, and a refusal being shown. New `src/lib/tags/similar.test.ts` covers which approved tags a suggestion looks like. - New `src/lib/operations/tags.test.ts` covers the rules once: listing (pending only for admins), suggesting (normalizing, existing tags, sign-in, malformed tags, the cap), approving (admins only, recording the approver and keeping the suggester, harmless repeats, not found), admins seeing who suggested and approved each tag and deleting (merge without duplicates, a `replace_with` that isn't approved or is empty, reject, refusing an approved tag). `src/lib/actions/tags.test.ts` now checks only the mapping, and `src/app/api/v1/tags/route.test.ts` checks that a suggestion answers 201 or 200. - `src/lib/operations/products.test.ts` adds a case where a product saves with a pending tag, and one where only callers who may edit a product see its pending tags. New `src/lib/clients/database/tags.test.ts` covers `hidePendingTags` taking pending tags off, keeping them where asked, and leaving other products as they are. `tags.test.ts` covers `reviewTag` refusing to merge or reject a tag that isn't pending. - `tags.test.ts` covers `anyPendingTags` saying yes when a tag is pending, no when none is, and no when the table can't be read. The new AccountDropdown stories were checked rendering in a local Storybook. - New `TagMenu.test.tsx` covers empty groups left out, the arrow keys moving across groups to the action and wrapping, and Enter and a click choosing. `src/lib/operations/products.test.ts` covers filtering by a pending tag returning nothing to a caller who can't see it. `TagPicker.test.tsx` covers no suggesting a tag the product already carries, the product's tags listed first and staying put when unchecked, Enter toggling without submitting the form, suggesting a tag (a *(new)* chip submitted as `suggest_tags`, with the save reminder, until removed), an existing tag not being offered for suggestion, and no suggesting without `suggestName`. `src/lib/operations/products.test.ts` covers a save suggesting only the tags the list lacks, a failing save suggesting nothing, an update adding suggestions to the product's own tags, and the cap refusing a save. `src/lib/actions/products.test.ts` covers the form sending `suggest_tags` and a refused suggestion showing under Tags. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
People can now suggest a tag from the product form's tag picker (source-cooperative/source.coop#648). This replaces the "contact hello@source.coop" line in Create a data product with how suggesting works: the tag goes on the product as pending, the team approves, merges or removes it, and each person can have five suggestions awaiting review. Stacked on #39; merge after it and alongside source.coop#648.
🤖 Generated with Claude Code